Skip to content

Java Spring Boot 快速上手指南

作者:Atom
字数统计:13.5k 字
阅读时长:56 分钟

本文面向有多年前端开发经验、熟悉 Node.js 后端(Express / EggJS / NestJS)的开发者, 从零搭建一个 Spring Boot 项目, 逐步深入到分层架构、数据库操作、安全认证、缓存、消息队列等企业级实践。每个概念都会与前端 / Node.js 生态中的对应物做类比, 帮你快速建立 Java 世界的心智模型


一、为什么是 Spring Boot

在 Java 生态中, Spring 就像前端的 React / Vue 一样, 是事实上的标准框架。而 Spring Boot 则是对 Spring 的开箱即用封装, 类似于 create-react-app 之于 React, 或者 EggJS 之于 Koa

对前端开发者的类比

Java / Spring BootNode.js / 前端
Spring BootEggJS / NestJS(约定式框架)
Maven / Gradlenpm / pnpm(包管理 + 构建)
application.yml.env + config/ 配置文件
@RestControllerExpress 的 router / Koa 的 controller
@ServiceEggJS 的 service
@Repository (JPA)Sequelize / TypeORM / Prisma
@Autowired (依赖注入)NestJS 的 @Inject()
Spring AOPExpress 中间件 / NestJS 拦截器
pom.xml / build.gradlepackage.json

Spring Boot 的核心优势

  1. 自动配置 (Auto Configuration): 引入一个 starter 依赖, 相关配置自动生效, 无需手动 XML 配置
  2. 内嵌服务器: 自带 Tomcat, 不需要单独部署 WAR 包, 直接 java -jar 启动
  3. Starter 依赖: 一个 starter 打包了一组常用依赖, 类似于 create-react-app 帮你预装了 webpack + babel + eslint
  4. 生产就绪: 内置健康检查、指标监控、外部化配置等运维能力

二、环境准备与项目创建

2.1 环境清单

工具版本要求说明
JDK17+ (推荐 21 或 25, 均为 LTS)Java 开发工具包, 类似 Node.js 运行时
Maven 或 GradleMaven 3.8+ / Gradle 7+构建工具, 类似 npm / pnpm
IDEIntelliJ IDEA (推荐)Java 开发首选, 类似前端的 VS Code

macOS 快速安装

sh
# 使用 SDKMAN 管理 Java 版本 (类似 nvm)
curl -s "https://get.sdkman.io" | bash
sdk install java 21-tem     # 或 25-tem, 两者都是 LTS

# 验证
java -version

2.2 创建项目

访问 Spring Initializr 生成项目骨架, 这就像前端用 npm create vite@latest 创建项目一样

推荐初始依赖

  • Spring Web: 构建 REST API 的核心
  • Spring Data JPA: ORM 框架, 类似 TypeORM / Prisma
  • MySQL Driver: 数据库驱动
  • Lombok: 减少 Java 样板代码(getter / setter / constructor)
  • Spring Boot DevTools: 热重载, 类似 nodemon
  • Validation: 参数校验, 类似 class-validator

2.3 项目目录结构

生成后的项目结构如下, 与 EggJS 的约定式目录有异曲同工之妙:

my-project/
├── src/
│   ├── main/
│   │   ├── java/com/example/demo/     # Java 源码 (类似 src/)
│   │   │   ├── DemoApplication.java   # 入口文件 (类似 app.js / main.ts)
│   │   │   ├── controller/            # 控制器 (类似 router + controller)
│   │   │   ├── service/               # 业务逻辑 (类似 service/)
│   │   │   ├── repository/            # 数据访问 (类似 model/)
│   │   │   ├── entity/                # 实体类 (类似 ORM 的 entity)
│   │   │   ├── dto/                   # 数据传输对象 (类似 TS 的 interface/type)
│   │   │   └── config/                # 配置类 (类似 config/)
│   │   └── resources/
│   │       ├── application.yml        # 主配置 (类似 .env + config)
│   │       ├── application-dev.yml    # 开发环境配置
│   │       └── application-prod.yml   # 生产环境配置
│   └── test/                          # 测试 (类似 __tests__/)
├── pom.xml                            # Maven 依赖 (类似 package.json)
└── mvnw / gradlew                     # 构建工具 wrapper (类似 npx)

三、Java 语言核心速览

写在前面

如果你熟悉 TypeScript, 那 Java 对你来说并不陌生。Java 是强类型语言, TypeScript 的类型系统正是受 Java / C# 启发而来

3.1 类型系统对比

java
// 基本类型 (Primitive Types)
int count = 10;              // TS: number
long bigCount = 100000L;     // TS: number (大整数)
double price = 99.9;         // TS: number
boolean active = true;       // TS: boolean
String name = "Atom";        // TS: string

// 包装类型 (可以为 null, 类似 TS 的 number | null)
Integer nullableCount = null;
Long nullableLong = null;

// 集合类型
List<String> names = List.of("a", "b");         // TS: string[]
Map<String, Object> map = Map.of("key", "val"); // TS: Record<string, any>
Set<String> uniqueNames = Set.of("a", "b");     // TS: Set<string>
typescript
// 基本类型
let count: number = 10;
let bigCount: number = 100000;
let price: number = 99.9;
let active: boolean = true;
let name: string = "Atom";

// 可空类型
let nullableCount: number | null = null;

// 集合类型
let names: string[] = ["a", "b"];
let map: Record<string, any> = { key: "val" };
let uniqueNames: Set<string> = new Set(["a", "b"]);

3.2 类与接口

java
// Java 的 class 比 TS 更严格, 一个文件通常只有一个 public class
public class User {
    private Long id;
    private String name;
    private String email;

    // 构造函数
    public User(Long id, String name, String email) {
        this.id = id;
        this.name = name;
        this.email = email;
    }

    // Getter / Setter (Java 的传统, 类似 TS 的属性访问)
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}
java
// Lombok 通过注解自动生成 getter/setter/constructor
// 类似 TS 直接声明 public 属性
@Data                          // 自动生成 getter + setter + toString + equals + hashCode
@NoArgsConstructor             // 无参构造
@AllArgsConstructor            // 全参构造
public class User {
    private Long id;
    private String name;
    private String email;
}
typescript
// TS 的 class, 简洁很多
class User {
    constructor(
        public id: number,
        public name: string,
        public email: string
    ) {}
}

Lombok 必知注解

注解作用TS 类比
@Data生成 getter/setter/toString/equals/hashCodeclass 的 public 属性
@Builder生成 Builder 模式构造对象字面量 { ...spread }
@NoArgsConstructor生成无参构造constructor()
@AllArgsConstructor生成全参构造constructor(all params)
@RequiredArgsConstructor生成 final 字段构造NestJS constructor(private readonly svc)
@Slf4j生成日志对象const logger = console

3.3 注解 (Annotation) = 装饰器 (Decorator)

Java 的注解 @Xxx 和 TypeScript / NestJS 的装饰器 @Xxx 几乎是同一个概念:

java
@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        // ...
    }

    @PostMapping
    public User createUser(@RequestBody @Valid CreateUserDTO dto) {
        // ...
    }
}
typescript
@Controller('/api/users')
export class UserController {

    @Get('/:id')
    getUser(@Param('id') id: number) {
        // ...
    }

    @Post()
    createUser(@Body() dto: CreateUserDTO) {
        // ...
    }
}

四、第一个 REST API

4.1 入口文件

java
@SpringBootApplication
public class DemoApplication {
    public static void main(String[] args) {
        // 类似 Node.js 的 app.listen(3000)
        SpringApplication.run(DemoApplication.class, args);
    }
}

@SpringBootApplication 是一个组合注解, 它等价于:

4.2 编写 Controller

与 Express 的对比

在 Express 中, 你会这样写路由:

js
router.get('/api/hello', (req, res) => {
  res.json({ message: 'Hello World' })
})

在 Spring Boot 中, 用注解来声明路由:

java
@RestController
@RequestMapping("/api/v1")
public class HelloController {

    @GetMapping("/hello")
    public Map<String, String> hello() {
        return Map.of("message", "Hello Spring Boot!");
    }

    @GetMapping("/hello/{name}")
    public Map<String, String> helloName(@PathVariable String name) {
        return Map.of("message", "Hello, " + name + "!");
    }

    @GetMapping("/search")
    public Map<String, Object> search(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size) {
        return Map.of("page", page, "size", size);
    }
}

常用路由注解速查

注解HTTP 方法Express 对应
@GetMappingGETrouter.get()
@PostMappingPOSTrouter.post()
@PutMappingPUTrouter.put()
@DeleteMappingDELETErouter.delete()
@PatchMappingPATCHrouter.patch()
@PathVariable路径参数req.params.id
@RequestParam查询参数req.query.page
@RequestBody请求体req.body
@RequestHeader请求头req.headers['x-token']

4.3 启动与验证

sh
# Maven 项目
./mvnw spring-boot:run

# Gradle 项目
./gradlew bootRun

# 访问
curl http://localhost:8080/api/v1/hello
# {"message":"Hello Spring Boot!"}

热重载

添加 spring-boot-devtools 依赖后, 修改代码会自动重启应用, 类似 nodemon。在 IDEA 中需要开启 Build project automatically 设置


五、配置管理

5.1 application.yml

Spring Boot 使用 application.yml(或 .properties)管理配置, 类似 Node.js 项目的 .env + config/

yaml
# application.yml - 主配置
server:
  port: 8080                    # 类似 Express 的 app.listen(8080)

spring:
  application:
    name: my-app                # 应用名称
  profiles:
    active: dev                 # 激活的环境 (类似 NODE_ENV=development)
  datasource:
    url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: 123456
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update          # 自动建表/更新表结构
    show-sql: true              # 打印 SQL (开发时开启)

ddl-auto 千万不要带进生产

ddl-auto: update 让 Hibernate 按实体类反向修改表结构, 开发期方便, 生产环境是事故源: 它只加不减, 字段改名会变成"新增一列 + 旧列残留", 而某些版本对索引和约束的处理并不可靠

取值行为适用环境
none什么都不做生产 (强制)
validate只校验表结构与实体是否一致, 不一致就启动失败生产 (推荐)
update增量修改表结构本地开发
create-drop启动建表, 关闭删表单元测试

生产环境的表结构变更应交给 Flyway / Liquibase 管理(类似前端的数据库 migration 脚本), 版本化、可回滚、可审计

5.2 多环境配置

yaml
server:
  port: 8080
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb_dev
logging:
  level:
    root: DEBUG
yaml
server:
  port: 80
spring:
  datasource:
    url: jdbc:mysql://prod-db:3306/mydb_prod
logging:
  level:
    root: WARN

5.3 自定义配置绑定

java
// 类似 Node.js 从 config 读取自定义配置
@Data
@Component
@ConfigurationProperties(prefix = "app")
public class AppConfig {
    private String name;
    private String version;
    private Jwt jwt = new Jwt();

    @Data
    public static class Jwt {
        private String secret;
        private long expiration = 86400000; // 24h
    }
}
yaml
# application.yml
app:
  name: my-app
  version: 1.0.0
  jwt:
    secret: my-secret-key
    expiration: 86400000
java
// 在任何地方注入使用
@Service
@RequiredArgsConstructor
public class AuthService {
    private final AppConfig appConfig;

    public String getSecret() {
        return appConfig.getJwt().getSecret();
    }
}

六、IoC 与依赖注入

先分清两个"依赖"

初学 Java 最容易混的一组概念: 依赖管理(Dependency Management)和 依赖注入(Dependency Injection)。名字都带"依赖", 但处在完全不同的阶段

依赖管理依赖注入
阶段构建期运行期
执行者Maven / GradleSpring 容器
改哪个文件pom.xml / build.gradleJava 源码
解决什么去哪里下载哪个 jar 包把哪个实例塞进哪个字段
前端类比package.json 加一行依赖NestJS 的 constructor(private svc)

所以日常写业务时加个注入点, 是不需要碰构建文件的:

java
// 只改这个 Java 文件就够了, build.gradle 不用动
@Service
@RequiredArgsConstructor
public class OrderService {
    private final UserRepository userRepository;   // 新增一行注入
    private final RedisTemplate<String, Object> redisTemplate;
}

只有一种情况需要两边都改: 要注入的类来自项目还没引入的库。比如想用 Excel 导出, 先在构建文件里声明依赖拿到"类", Spring 才能给你"实例":

groovy
// ① 第一步: build.gradle 拿到 jar (构建期)
implementation "org.apache.poi:poi-ooxml:5.2.5"
java
// ② 第二步: 同步依赖后, 才能在代码里注入 (运行期)
private final ExcelExportService excelExportService;

顺序永远是: 构建工具先拿到 , Spring 容器才能给出 实例

6.1 什么是 IoC

IoC(Inversion of Control, 控制反转) 是 Spring 的灵魂。简单说就是: 你不需要自己 new 对象, Spring 容器帮你创建和管理

对前端的类比

如果你用过 NestJS, 它的依赖注入系统就是从 Spring 借鉴而来:

typescript
// NestJS - 和 Spring 几乎一模一样
@Injectable()
export class UserService {
    constructor(private readonly userRepo: UserRepository) {}
}
java
// Spring Boot
@Service
public class UserService {
    private final UserRepository userRepo;

    // 构造器注入 (推荐)
    public UserService(UserRepository userRepo) {
        this.userRepo = userRepo;
    }
}

6.2 Bean 的注册方式

Spring 中被容器管理的对象叫做 Bean, 类似 NestJS 中被 @Injectable() 标记的 Provider

注解用途NestJS 对应
@Component通用组件@Injectable()
@Service业务逻辑层@Injectable() (Service)
@Repository数据访问层@Injectable() (Repository)
@Controller / @RestController控制器@Controller()
@Configuration + @Bean手动注册providers: [{ provide: ..., useFactory: ... }]

6.3 注入方式

java
@Service
@RequiredArgsConstructor  // Lombok 自动生成构造器
public class UserService {
    // final 字段 = 必须注入, 类似 NestJS 的 private readonly
    private final UserRepository userRepository;
    private final RedisTemplate<String, String> redisTemplate;
}
java
@Service
public class UserService {
    // 不推荐: 无法在测试中轻松 mock
    @Autowired
    private UserRepository userRepository;
}

为什么推荐构造器注入

  1. 不可变性: final 字段保证注入后不会被修改
  2. 可测试性: 测试时直接通过构造器传入 mock 对象
  3. 明确依赖: 一眼看出这个类依赖了什么
  4. NullSafe: 编译期就能发现缺失的依赖, 而不是运行时 NPE

6.4 Bean 的名字与冲突仲裁

容器里的每个 Bean 都有一个唯一的名字, 默认取类名首字母小写(UserServiceuserService), 用 @Bean 注册时则取方法名

平时按类型注入就能命中, 但当同一个类型存在多个 Bean 时, 容器就不知道该给哪个了, 启动直接失败:

text
NoUniqueBeanDefinitionException: expected single matching bean but found 2:
  primaryDataSource, secondaryDataSource

两个解决手段:

java
@Configuration
public class DataSourceConfig {

    @Bean
    @Primary                      // 同类型有多个候选时, 优先选我
    public DataSource primaryDataSource() {
        return buildDataSource("jdbc:mysql://main-db:3306/app");
    }

    @Bean
    public DataSource secondaryDataSource() {
        return buildDataSource("jdbc:mysql://backup-db:3306/app");
    }
}
java
@Service
public class ReportService {

    private final DataSource dataSource;

    // 不按类型猜了, 明确要名字叫 secondaryDataSource 的那个
    // 注意: @Qualifier 必须写在构造器参数上
    public ReportService(@Qualifier("secondaryDataSource") DataSource dataSource) {
        this.dataSource = dataSource;
    }
}

@Qualifier 和 @RequiredArgsConstructor 有个坑

上面这段之所以手写构造器, 是因为 Lombok 默认不会把字段上的 @Qualifier 复制到它生成的构造器参数上。下面这种写法看着合理, 实际注入的仍是 @Primary 那个, 而且不报错:

java
@Service
@RequiredArgsConstructor
public class ReportService {
    @Qualifier("secondaryDataSource")      // ❌ 静默失效
    private final DataSource dataSource;
}

两个解法, 任选其一:

① 手写构造器(如上), 最直观, 不依赖额外配置

② 在项目根目录建 lombok.config, 告诉 Lombok 把这个注解带过去:

properties
# lombok.config
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

配好之后字段写法才真正生效。这类"不报错但行为不对"的问题最难排查, 遇到多数据源、多线程池、多 RedisTemplate 这些同类型多 Bean 的场景要格外留神

注入 Map / List 可以批量收集同类型 Bean

这是个非常好用的技巧: 把注入目标声明成 MapList, Spring 会把所有该类型的实现塞进来 —— Map 的 key 是 Bean 名字, List 则按 @Order 排序

java
@Component
@RequiredArgsConstructor
public class PaymentFactory {

    // 容器把所有 PaymentHandler 实现都收集进来
    private final Map<String, PaymentHandler> handlerMap;

    public PaymentHandler get(String channel) {
        return handlerMap.get(channel + "PaymentHandler");
    }
}

好处是加新实现不用改工厂类: 新写一个 @Service class WechatPaymentHandler implements PaymentHandler, 它自动出现在 map 里。这是策略模式在 Spring 里最省事的落地方式, 比手写 switch 干净得多

6.5 Bean 的作用域与线程安全

Spring Bean 默认是 单例(singleton): 整个应用只有一个实例, 被所有注入方共享。这跟 Node.js 里 module.exports 一个对象、全进程复用是同一个道理

作用域说明使用频率
singleton默认, 全局一个实例99%
prototype每次注入 / 获取都新建少见
request每个 HTTP 请求一个Web 场景偶用
session每个会话一个罕见

单例 Bean 里绝对不要放可变状态

Spring MVC 是多线程模型(一个请求一个线程), 单例 Bean 会被并发访问。把请求数据存成字段, 多个用户的数据会互相覆盖 —— 这类 bug 在本地单人测试时完全复现不出来, 上线才爆

java
@Service
public class BadService {
    private Long currentUserId;                    // ❌ 灾难

    public void handle(Long userId) {
        this.currentUserId = userId;               // 线程 A 写入
        doSomething();                             // 线程 B 可能已经改掉了
    }
}
java
@Service
@RequiredArgsConstructor
public class GoodService {
    private final UserRepository userRepository;   // ✅ 只存无状态的协作对象

    public void handle(Long userId) {
        // 请求数据一律走方法参数和局部变量
        User user = userRepository.findById(userId).orElseThrow();
    }
}

判断标准很简单: 字段只放 final 的依赖引用, 数据全部走参数。前端写 Node.js 时如果踩过"模块级变量被并发请求污染"的坑, 这里是完全一样的机理


七、分层架构实战

一个完整的 Spring Boot 应用采用经典的三层架构, 与 EggJS / NestJS 如出一辙:

7.1 Entity 实体类

java
@Data
@Entity
@Table(name = "t_user")
@DynamicUpdate
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 50)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(unique = true, length = 100)
    private String email;

    @Column(name = "phone_number", length = 20)
    private String phoneNumber;

    @Enumerated(EnumType.STRING)
    @Column(length = 20)
    private UserStatus status = UserStatus.ACTIVE;

    @Column(name = "created_at", updatable = false)
    @CreationTimestamp
    private LocalDateTime createdAt;

    @Column(name = "updated_at")
    @UpdateTimestamp
    private LocalDateTime updatedAt;

    public enum UserStatus {
        ACTIVE, INACTIVE, BANNED
    }
}

与 TypeORM 的对比

typescript
// TypeORM Entity - 几乎一模一样的概念
@Entity('t_user')
export class User {
    @PrimaryGeneratedColumn()
    id: number

    @Column({ nullable: false, length: 50 })
    username: string

    @CreateDateColumn()
    createdAt: Date
}

@Data 用在 Entity 上是个坑

@Data 会一并生成 toString / equals / hashCode, 这三个方法碰上 JPA 会出问题:

  • 双向关联无限递归: 用户 toString 打印部门, 部门 toString 又打印用户列表, 直接 StackOverflowError
  • 懒加载被意外触发: toString 访问了 LAZY 字段, 在 session 关闭后抛 LazyInitializationException
  • hashCode 不稳定: 实体存进 HashSet 后再改字段, hashCode 跟着变, 之后就再也取不出来了

实体类推荐这样写:

java
@Getter
@Setter
@Entity
@Table(name = "t_user")
@ToString(exclude = "department")                    // 排除关联字段
@EqualsAndHashCode(onlyExplicitlyIncluded = true)    // 只用显式标记的字段
public class User {

    @Id
    @EqualsAndHashCode.Include                      // 只按主键判等
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
}

DTO / VO 没有关联关系和持久化上下文, 用 @Data 完全没问题

7.2 DTO 数据传输对象

DTO 是 Controller 层和 Service 层之间的数据契约, 类似 TypeScript 的 interface + 校验规则:

java
// 创建用户请求 DTO
@Data
public class CreateUserDTO {

    @NotBlank(message = "用户名不能为空")
    @Size(min = 2, max = 50, message = "用户名长度 2-50 个字符")
    private String username;

    @NotBlank(message = "密码不能为空")
    @Size(min = 6, max = 100, message = "密码长度 6-100 个字符")
    private String password;

    @Email(message = "邮箱格式不正确")
    private String email;

    @Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
    private String phoneNumber;
}

// 用户响应 DTO (不暴露密码等敏感字段)
@Data
@Builder
public class UserVO {
    private Long id;
    private String username;
    private String email;
    private String phoneNumber;
    private String status;
    private LocalDateTime createdAt;
}

DTO vs Entity 的关系

  • Request DTO: 接收前端参数 + 校验, 类似 class-validator 的 DTO
  • Entity: 映射数据库表, 内部使用
  • Response VO: 返回给前端的数据, 隐藏敏感信息

7.3 Repository 数据访问层

Spring Data JPA 让你只需定义接口, 不用写实现, 框架自动生成 SQL:

java
@Repository
public interface UserRepository extends JpaRepository<User, Long>,
                                        JpaSpecificationExecutor<User> {

    // 方法名即查询! Spring 自动生成 SQL
    // SELECT * FROM t_user WHERE username = ?
    Optional<User> findByUsername(String username);

    // SELECT * FROM t_user WHERE email = ?
    Optional<User> findByEmail(String email);

    // SELECT * FROM t_user WHERE status = ? AND created_at > ?
    List<User> findByStatusAndCreatedAtAfter(User.UserStatus status, LocalDateTime time);

    // 支持分页
    Page<User> findByStatus(User.UserStatus status, Pageable pageable);

    // 复杂查询可以手写 JPQL
    @Query("SELECT u FROM User u WHERE u.username LIKE %:keyword% OR u.email LIKE %:keyword%")
    Page<User> searchByKeyword(@Param("keyword") String keyword, Pageable pageable);

    // 判断是否存在
    boolean existsByUsername(String username);
    boolean existsByEmail(String email);
}

这里的 @Repository 其实可以省略

@EnableJpaRepositories(已被 @SpringBootApplication 自动开启)会扫描并注册所有继承 Repository 的接口, 不依赖 @Repository 注解。加上它只是让 IDE 和阅读者一眼看出层次, 语义上是冗余的

真正需要 @Repository 的场景是自己手写实现类的数据访问组件 —— 此时要靠它完成 Bean 注册, 并顺带获得持久层异常翻译(把各家数据库驱动的异常统一成 Spring 的 DataAccessException)

方法命名规则

Spring Data JPA 通过解析方法名自动生成查询, 类似 Prisma 的 findUnique / findMany:

方法名关键字生成的 SQLPrisma 对照
findByWHEREfindMany({ where: {} })
AndANDAND 条件
OrOROR 条件
OrderByORDER BYorderBy
BetweenBETWEENgte + lte
Like / ContainingLIKE %?%contains
InIN (?)in
IsNullIS NULLequals: null
countSELECT COUNT(*)count()
existsBySELECT EXISTS手动查

7.4 Service 业务逻辑层

java
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    /**
     * 创建用户
     */
    @Transactional
    public UserVO createUser(CreateUserDTO dto) {
        // 1. 业务校验
        if (userRepository.existsByUsername(dto.getUsername())) {
            throw new BusinessException("用户名已存在");
        }
        if (dto.getEmail() != null && userRepository.existsByEmail(dto.getEmail())) {
            throw new BusinessException("邮箱已被注册");
        }

        // 2. DTO -> Entity
        User user = new User();
        user.setUsername(dto.getUsername());
        user.setPassword(passwordEncoder.encode(dto.getPassword()));
        user.setEmail(dto.getEmail());
        user.setPhoneNumber(dto.getPhoneNumber());

        // 3. 持久化
        User saved = userRepository.save(user);
        log.info("用户创建成功: id={}, username={}", saved.getId(), saved.getUsername());

        // 4. Entity -> VO
        return toVO(saved);
    }

    /**
     * 分页查询用户
     */
    public Page<UserVO> getUsers(int page, int size) {
        Pageable pageable = PageRequest.of(page - 1, size, Sort.by("createdAt").descending());
        return userRepository.findAll(pageable).map(this::toVO);
    }

    /**
     * 根据 ID 查询
     */
    public UserVO getUserById(Long id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new BusinessException("用户不存在"));
        return toVO(user);
    }

    /**
     * Entity -> VO 转换
     */
    private UserVO toVO(User user) {
        return UserVO.builder()
                .id(user.getId())
                .username(user.getUsername())
                .email(user.getEmail())
                .phoneNumber(user.getPhoneNumber())
                .status(user.getStatus().name())
                .createdAt(user.getCreatedAt())
                .build();
    }
}

@Transactional 事务管理

@Transactional 注解标记的方法, 会在一个数据库事务中执行, 任何异常都会自动回滚。类似 Sequelize 的:

js
await sequelize.transaction(async (t) => {
  await User.create(data, { transaction: t })
})

Spring 只需要加一个注解, 不用手动管理事务的开始和提交

7.5 Controller 控制器层

java
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/users")
public class UserController {

    private final UserService userService;

    @PostMapping
    public ResponseEntity<UserVO> createUser(@RequestBody @Valid CreateUserDTO dto) {
        UserVO user = userService.createUser(dto);
        return ResponseEntity.status(HttpStatus.CREATED).body(user);
    }

    @GetMapping
    public ResponseEntity<Page<UserVO>> getUsers(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size) {
        return ResponseEntity.ok(userService.getUsers(page, size));
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserVO> getUserById(@PathVariable Long id) {
        return ResponseEntity.ok(userService.getUserById(id));
    }
}

两种返回风格不要混用

上面用的是 ResponseEntity<T>, 靠 HTTP 状态码表达结果; 下一章会介绍统一响应体 R<T>, 靠 body 里的 code 字段表达结果。两者是并列的选型, 一个项目里只该选一种:

风格返回类型前端判断方式适合
HTTP 语义ResponseEntity<UserVO>res.status对外开放 API, RESTful 规范严格
统一响应体R<UserVO>res.data.code内部前后端联调, 错误码体系复杂

混用会让前端拦截器写两套判断逻辑。国内业务项目多数选后者, 此时 Controller 直接返回 R.ok(data), 由全局异常处理器兜住失败分支

7.6 POJO 到底是什么

pojo 这个目录几乎出现在每个 Java 项目里, 但它的含义最容易被误解

字面上, POJO = Plain Old Java Object, "普通老式 Java 对象"。这是个历史遗留词: 早年 EJB 时代, 一个对象必须继承框架基类、实现一堆接口才能用, 非常笨重; 后来社区回归"就是个带字段和 getter / setter 的普通类", 为了跟 EJB 划清界限, 起名叫 POJO

所以 POJO 本身不是一个分层概念

严格说 Entity、DTO、VO 全都是 POJO —— 它们都只是普通 Java 类。你没法通过"是不是 POJO"来判断一个类属于哪一层

但在工程实践中, pojo/ 目录被约定成一个具体用途: 存放接口边界上的对象, 也就是 Request 和 Response。这是团队约定, 不是语言规定

实际项目里常见的组织方式是用一个外层类做命名空间, 把同一个接口的入参和出参收在一个文件里:

java
// 外层类只是个壳子, 从不实例化
public class UserApiPojo implements Serializable {

    @Data
    public static class CreateRequest implements Serializable {

        @NotBlank(message = "用户名不能为空")
        @Size(min = 2, max = 50, message = "用户名长度 2-50 个字符")
        private String username;

        @NotNull(message = "部门 ID 不能为空")
        private Long departmentId;
    }

    @Data
    public static class CreateResponse implements Serializable {
        private Long userId;
    }

    @Data
    public static class ListRequest implements Serializable {
        private String keyword;
        private Integer page = 1;
        private Integer size = 10;
    }
}

使用时写全路径, 一眼看出归属:

java
@PostMapping
public R<UserApiPojo.CreateResponse> create(
        @Valid @RequestBody UserApiPojo.CreateRequest request) {
    return R.ok(userService.create(request));
}

三个实践要点

  1. 内部类必须是 public static —— 非静态内部类会隐式持有外层实例引用, Jackson 反序列化时会失败
  2. 校验注解写在这里, 但要配合 @Valid 才生效 —— Controller 参数上少写一个 @Valid, 所有 @NotBlank 全部形同虚设, 这是极高频的疏漏
  3. 命名跟着项目走 —— 出口对象在不同项目里有 XxxVO / XxxResult / XxxResponse / XxxApiPojo 多种叫法, 甚至同一个仓库里几种并存。改哪个模块就照那个模块现有的命名, 别自己另起一套

这套对象谱系还有更细的分法

DTO / VO / BO / PO 之间的区别、贫血模型与充血模型之争, 属于领域建模的范畴, 展开够写一篇独立文章。想深入可以看 Java 后端分层架构与领域对象

对刚上手的人来说, 先记住最小可用的三段就够: 入口对象收参 → Entity 落库 → 出口对象返回

7.7 Mapper: 编译期的对象转换器

分层带来一个副作用: 同一份数据在链路上要换好几次外衣, 每次都得手写一长串 set

java
// 手写转换: 啰嗦, 而且加字段时极易漏
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setUsername(user.getUsername());
vo.setEmail(user.getEmail());
// ... 还有 15 个字段

MapStruct 就是来解决这件事的

先排除两个同名干扰项

"Mapper"在 Java 生态里被三个完全不同的东西共用, 初学时极易串味:

名字是什么什么时候遇到
org.mapstruct.Mapper对象转换器, 本节主角DTO ↔ Entity ↔ VO 转换
com.fasterxml.jackson.databind.ObjectMapperJSON 序列化工具手动转 JSON 字符串时
MyBatis 的 @MapperSQL 映射接口, 职责等同 Repository用 MyBatis 而非 JPA 的项目

如果你听过"Mapper 是写 SQL 的地方", 那说的是 MyBatis, 跟 MapStruct 毫无关系

MapStruct 的关键特征是编译期生成代码, 而不是运行时反射:

带来三个好处: 零反射开销、字段漏映射在编译期就警告、改字段名直接编译不过(而不是上线后才发现某个字段一直是 null)

基础用法 —— 只声明抽象方法, 同名字段自动对应:

java
@Mapper(componentModel = "spring")   // 生成的实现类带 @Component, 可注入
public interface UserMapper {

    // ① 单对象转换
    UserVO toVO(User user);

    // ② 集合转换: 也是全自动, 内部循环调用 ①
    List<UserVO> toVOList(List<User> users);

    // ③ 反向转换
    User toEntity(UserApiPojo.CreateRequest request);
}

注入后直接用:

java
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final UserMapper userMapper;          // 注入生成的实现

    public List<UserVO> list() {
        return userMapper.toVOList(userRepository.findAll());
    }
}

字段对不上时, 用 @Mapping 逐个指定:

java
@Mapper(componentModel = "spring")
public interface UserMapper {

    @Mapping(target = "departmentName", source = "department.name")   // 取嵌套属性
    @Mapping(target = "statusText", expression = "java(user.getStatus().getLabel())")  // 自定义表达式
    @Mapping(target = "password", ignore = true)                      // 显式不映射
    UserVO toVO(User user);
}

需要混写自定义逻辑时, 把 interface 改成 abstract class

接口只能放抽象方法。一旦某个转换需要写循环、排序或条件判断, 就改用抽象类 —— 具体方法手写, 单个对象的转换仍交给 MapStruct 生成:

java
@Mapper(componentModel = "spring")
public abstract class UserMapper {

    // 手写: Set 入、有序 List 出, 顺带排序
    public List<UserVO> toSortedVOList(Set<User> users) {
        return users.stream()
                .map(this::toVO)                              // 调下面这个自动生成的
                .sorted(Comparator.comparing(UserVO::getUsername))
                .collect(Collectors.toList());
    }

    // 留给 MapStruct 生成
    protected abstract UserVO toVO(User user);
}

前端有没有对应物

没有直接等价的。最接近的是手写 function toVO(dto) { return { ... } }, 或者 class-transformerplainToInstance

差别在于 MapStruct 生成的是真实可读的 Java 代码 —— 编译后能在 target/generated-sources(Maven)或 build/generated(Gradle)里翻到 UserMapperImpl.java, 逐行确认它到底怎么赋值的。排查"某字段莫名是 null"时, 直接去读生成的代码比猜快得多

7.8 完整链路复盘

把七章的角色串成一条线:

各层职责速查:

角色典型位置职责能否跨层暴露
入口对象pojo/XxxRequest接收参数 + 声明校验规则只到 Service
Entityentity/映射存储结构禁止返回给前端
Repositoryrepository/只管存取, 不含业务逻辑只被 Service 调用
Serviceservice/业务编排、事务边界——
DTOdto/层间传递的中间结果Service → Controller
出口对象pojo/XxxVO前端契约, 脱敏裁剪返回给前端
Mappermapper/对象之间的字段搬运工具, 无状态

为什么不能直接把 Entity 返给前端

三个具体后果, 都很容易踩:

  1. 敏感字段泄露 —— password、内部 ID、审计字段、软删除标记全被序列化出去
  2. 数据库与接口耦合 —— 表加个字段, 接口响应结构跟着变, 前端被动受影响
  3. 懒加载炸裂 —— LAZY 关联字段在事务结束后序列化, 直接抛 LazyInitializationException

反过来也别为了分层而分层: 简单的查询用一个对象从头传到尾完全可以, 不必每层都造新类型

初学者的四个典型误解

自查一下, 这几点是最容易想歪的:

① "Entity 就是数据库表的映射" —— 方向对, 范围偏窄。准确说是"存储结构的映射"。同一个 entity/ 目录下可能混着两类: @Entity + @Table 映射关系型表, @Document 映射 Elasticsearch 索引。两者都叫 Entity, 但底层框架完全不同

② "Repository 里面封装了 ORM 工具" —— 反了。Repository 本身就是 ORM 框架提供的抽象层, 不是"内部含有 ORM"。JPA 这边的实际 ORM 实现是 Hibernate, ES 那边则由 Spring Data Elasticsearch 负责。不同存储各用各的 Repository 基接口, 而非一个 Repository 内部挂多个 ORM

③ "Repository 需要写实现类" —— 不需要, 这是跟 TypeORM 差别最大的地方。你只写接口, Spring 启动时用动态代理生成实现并注册成 Bean。方法名本身就是查询定义: findAllByStatusAndCreatedAtBetween 被解析成 where status = ? and created_at between ? and ?。TypeORM 是运行时传条件对象, 这里是编译前靠命名约定 —— 好处是拼错方法名启动就报错, 代价是复杂查询得回落到 @QuerySpecification

④ "DTO 管请求和响应, VO 是最终出口" —— 流转方向理解对了, 但角色分配因项目而异。很多项目的入口和出口对象都放在 pojo/, dto/ 只装 Service 层的中间结果。别背教科书定义, 打开目录看现有代码怎么摆的


八、统一响应与异常处理

8.1 统一响应格式

前端最熟悉的后端约定: 统一的 JSON 响应结构

java
@Data
@Builder
public class R<T> {
    private int code;
    private String message;
    private T data;

    public static <T> R<T> ok(T data) {
        return R.<T>builder()
                .code(200)
                .message("success")
                .data(data)
                .build();
    }

    public static <T> R<T> fail(int code, String message) {
        return R.<T>builder()
                .code(code)
                .message(message)
                .build();
    }
}

8.2 全局异常处理

类似 Express 的错误处理中间件, Spring Boot 用 @ControllerAdvice 捕获全局异常:

java
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 业务异常
     */
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<R<Void>> handleBusinessException(BusinessException e) {
        log.warn("业务异常: {}", e.getMessage());
        return ResponseEntity.badRequest().body(R.fail(400, e.getMessage()));
    }

    /**
     * 参数校验异常 (DTO 的 @Valid 校验失败时触发)
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<R<Void>> handleValidException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(FieldError::getDefaultMessage)
                .collect(Collectors.joining(", "));
        return ResponseEntity.badRequest().body(R.fail(400, message));
    }

    /**
     * 兜底: 未知异常
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<R<Void>> handleException(Exception e) {
        log.error("系统异常", e);
        return ResponseEntity.internalServerError().body(R.fail(500, "服务器内部错误"));
    }
}

Express 对比

javascript
// Express 的全局错误处理中间件
app.use((err, req, res, next) => {
    if (err instanceof BusinessError) {
        return res.status(400).json({ code: 400, message: err.message })
    }
    res.status(500).json({ code: 500, message: '服务器内部错误' })
})

Spring Boot 的 @RestControllerAdvice 做的事情完全一样, 只是用注解代替了中间件


九、Spring AOP 与拦截器

9.1 AOP 面向切面编程

AOP 是 Spring 的核心特性之一。如果说依赖注入解决了"对象怎么创建和组装"的问题, 那 AOP 解决的就是"怎么在不修改业务代码的情况下, 统一添加日志、权限、事务等横切关注点"

对 Express / Koa 中间件的类比

Spring 机制Express / Koa 对应执行时机
Filterapp.use(cors()) / app.use(bodyParser())所有请求, 最外层
Interceptorapp.use(authMiddleware)进入 Controller 前后
@Aspect (AOP)NestJS 的 @UseInterceptors()方法执行前后
@ControllerAdviceapp.use(errorHandler)异常发生时

9.2 自定义拦截器

java
@Slf4j
@Component
public class RequestLogInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
                             Object handler) {
        long startTime = System.currentTimeMillis();
        request.setAttribute("startTime", startTime);
        log.info("[请求开始] {} {}", request.getMethod(), request.getRequestURI());
        return true; // true = 放行, false = 拦截
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                Object handler, Exception ex) {
        long startTime = (Long) request.getAttribute("startTime");
        long duration = System.currentTimeMillis() - startTime;
        log.info("[请求结束] {} {} - {}ms", request.getMethod(), request.getRequestURI(), duration);
    }
}

// 注册拦截器
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final RequestLogInterceptor requestLogInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(requestLogInterceptor)
                .addPathPatterns("/api/**")     // 拦截 /api/ 下的所有请求
                .excludePathPatterns("/api/v1/auth/**"); // 排除登录接口
    }
}

9.3 自定义 AOP 切面

实现一个方法执行耗时统计的切面:

java
@Aspect
@Component
@Slf4j
public class PerformanceAspect {

    // 切入点: 匹配所有 Service 层的 public 方法
    @Around("execution(* com.example.demo.service.*.*(..))")
    public Object logPerformance(ProceedingJoinPoint joinPoint) throws Throwable {
        String methodName = joinPoint.getSignature().toShortString();
        long start = System.currentTimeMillis();

        try {
            Object result = joinPoint.proceed(); // 执行目标方法
            long duration = System.currentTimeMillis() - start;
            if (duration > 500) {
                log.warn("[慢方法] {} 耗时 {}ms", methodName, duration);
            }
            return result;
        } catch (Exception e) {
            long duration = System.currentTimeMillis() - start;
            log.error("[方法异常] {} 耗时 {}ms, 异常: {}", methodName, duration, e.getMessage());
            throw e;
        }
    }
}

十、数据库进阶

10.1 动态查询 (JPA Specification)

当查询条件不固定时(比如搜索页面的多个可选筛选条件), 使用 Specification 动态拼接 WHERE 子句:

java
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public Page<UserVO> searchUsers(String keyword, User.UserStatus status,
                                    int page, int size) {
        Pageable pageable = PageRequest.of(page - 1, size);

        Specification<User> spec = (root, query, cb) -> {
            List<Predicate> predicates = new ArrayList<>();

            // 关键词搜索 (用户名或邮箱)
            if (StringUtils.hasText(keyword)) {
                Predicate nameLike = cb.like(root.get("username"), "%" + keyword + "%");
                Predicate emailLike = cb.like(root.get("email"), "%" + keyword + "%");
                predicates.add(cb.or(nameLike, emailLike));
            }

            // 状态筛选
            if (status != null) {
                predicates.add(cb.equal(root.get("status"), status));
            }

            return cb.and(predicates.toArray(new Predicate[0]));
        };

        return userRepository.findAll(spec, pageable).map(this::toVO);
    }
}

Prisma 对比

typescript
// Prisma 的动态查询
const users = await prisma.user.findMany({
    where: {
        OR: keyword ? [
            { username: { contains: keyword } },
            { email: { contains: keyword } },
        ] : undefined,
        status: status ?? undefined,
    },
    skip: (page - 1) * size,
    take: size,
})

两者的思路完全一致: 根据传入条件动态拼接查询

10.2 复杂关联查询

java
// 一对多关系
@Entity
@Table(name = "t_department")
@Data
public class Department {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    // 一个部门有多个用户
    @OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
    private List<User> users;
}

// 多对一关系
@Entity
@Table(name = "t_user")
@Data
public class User {
    // ...其他字段

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id")
    private Department department;
}

N+1 查询问题

JPA 默认使用懒加载(LAZY), 循环访问关联对象时会产生 N+1 查询问题(和 TypeORM / Sequelize 一样)。解决方案:

  1. JPQL fetch join: SELECT u FROM User u JOIN FETCH u.department
  2. EntityGraph: @EntityGraph(attributePaths = {"department"})
  3. 直接用 DTO 投影: 不查关联实体, 只查需要的字段

十一、Redis 缓存集成

11.1 基础配置

yaml
spring:
  data:
    redis:
      host: localhost
      port: 6379
      password: ""
      database: 0

11.2 使用 RedisTemplate

java
@Service
@RequiredArgsConstructor
public class CacheService {

    private final RedisTemplate<String, Object> redisTemplate;

    // 缓存用户信息 (类似 Node.js 的 redis.set)
    public void cacheUser(Long userId, UserVO user) {
        String key = "user:" + userId;
        redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
    }

    // 获取缓存
    public UserVO getCachedUser(Long userId) {
        String key = "user:" + userId;
        return (UserVO) redisTemplate.opsForValue().get(key);
    }

    // 删除缓存
    public void evictUser(Long userId) {
        redisTemplate.delete("user:" + userId);
    }
}

默认序列化器会让这段代码翻车

RedisTemplate 默认用 JDK 序列化, 有两个后果: 一是 redis-cli 里看到的是一串乱码, 二是 UserVO 增删字段后, 旧缓存反序列化直接抛 ClassCastException。而上面 (UserVO) 这种强制转型编译期不报错, 问题全留到运行时

必须显式配置 JSON 序列化器:

java
@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        // 值用 JSON, 可读且跨语言
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }
}

即便如此, 缓存对象也应保持向后兼容: 只加字段不改字段类型, 结构大改时换一个新的 key 前缀

11.3 注解式缓存

Spring Cache 提供了更优雅的声明式缓存, 只需加注解:

java
@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    // 查询时自动缓存, key = "user::1"
    @Cacheable(value = "user", key = "#id")
    public UserVO getUserById(Long id) {
        log.info("从数据库查询用户: {}", id);
        User user = userRepository.findById(id)
                .orElseThrow(() -> new BusinessException("用户不存在"));
        return toVO(user);
    }

    // 更新时自动更新缓存
    @CachePut(value = "user", key = "#id")
    public UserVO updateUser(Long id, UpdateUserDTO dto) {
        // ...更新逻辑
    }

    // 删除时自动清除缓存
    @CacheEvict(value = "user", key = "#id")
    public void deleteUser(Long id) {
        userRepository.deleteById(id);
    }
}

缓存注解速查

注解作用等效操作
@Cacheable先查缓存, 没有才执行方法cache.get(key) ?? (await fn())
@CachePut执行方法并更新缓存cache.set(key, fn())
@CacheEvict执行方法并删除缓存cache.del(key)

十二、安全认证 (JWT)

12.1 认证流程

12.2 JWT 工具类

java
@Component
@RequiredArgsConstructor
public class JwtUtil {

    private final AppConfig appConfig;

    // 生成 Token
    public String generateToken(Long userId, String username) {
        return Jwts.builder()
                .setSubject(String.valueOf(userId))
                .claim("username", username)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis()
                        + appConfig.getJwt().getExpiration()))
                .signWith(getSigningKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    // 解析 Token
    public Claims parseToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    // 从 Token 中获取用户 ID
    public Long getUserId(String token) {
        return Long.parseLong(parseToken(token).getSubject());
    }

    private Key getSigningKey() {
        // 注意: HS256 要求密钥至少 256 bit (32 字节), 过短会直接抛 WeakKeyException
        byte[] keyBytes = appConfig.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
        return Keys.hmacShaKeyFor(keyBytes);
    }
}

上面是 jjwt 0.11.x 的写法

jjwt 0.12 起做了一次 API 重命名, setXxx 系列和 parserBuilder() 全部标记废弃。如果你用的是新版本, 对应关系如下:

0.11.x0.12+
setSubject(...)subject(...)
setIssuedAt(...)issuedAt(...)
setExpiration(...)expiration(...)
signWith(key, SignatureAlgorithm.HS256)signWith(key) (自动推导算法)
Jwts.parserBuilder()Jwts.parser()
setSigningKey(...)verifyWith(...)
parseClaimsJws(t).getBody()parseSignedClaims(t).getPayload()

另外密钥不要硬编码在 application.yml 里提交进仓库, 应走环境变量或配置中心

12.3 Security 过滤器链配置

java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthFilter jwtAuthFilter;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            // 禁用 CSRF (前后端分离不需要)
            .csrf(csrf -> csrf.disable())
            // 禁用 Session (用 JWT 无状态认证)
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // 路由权限配置
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/**").permitAll()  // 登录注册公开
                .requestMatchers("/actuator/**").permitAll()     // 监控端点公开
                .anyRequest().authenticated()                     // 其他都需要认证
            )
            // 在 UsernamePasswordAuthenticationFilter 之前插入 JWT 过滤器
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

12.4 JWT 过滤器

java
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            try {
                Long userId = jwtUtil.getUserId(token);
                User user = userRepository.findById(userId).orElse(null);

                if (user != null) {
                    // 将用户信息放入 SecurityContext, 后续可通过 SecurityContextHolder 获取
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(user, null, List.of());
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            } catch (ExpiredJwtException e) {
                // 过期是预期内的高频情况, 降级为 debug, 避免日志被刷爆
                log.debug("Token 已过期: {}", e.getMessage());
            } catch (JwtException | IllegalArgumentException e) {
                // 签名不合法 / 格式错误, 可能是攻击探测, 留一条 warn
                log.warn("Token 解析失败: {}", e.getMessage());
            }
        }

        filterChain.doFilter(request, response);
    }
}

十三、消息队列集成

在大型项目中, 很多操作不需要同步完成(比如发送邮件、生成报告、写日志)。消息队列可以将这些耗时任务异步化

13.1 消息队列概念

与前端的类比

消息队列就像浏览器的 postMessage / BroadcastChannel, 或者 Node.js 的 EventEmitter:

javascript
// Node.js EventEmitter
emitter.emit('user:registered', { userId: 1 })

// 多个消费者监听
emitter.on('user:registered', sendWelcomeEmail)
emitter.on('user:registered', initUserProfile)

区别在于消息队列是跨进程、可持久化的, 即使消费者宕机, 消息也不会丢失

13.2 使用 RocketMQ

java
// 生产者: 发送消息
@Service
@RequiredArgsConstructor
public class MessageProducer {

    private final RocketMQTemplate rocketMQTemplate;

    public void sendUserRegisteredEvent(Long userId, String username) {
        UserRegisteredEvent event = new UserRegisteredEvent(userId, username);
        rocketMQTemplate.convertAndSend("user-registered-topic", event);
    }

    // 延迟消息 (比如: 注册后 30 分钟发问卷)
    public void sendDelayedMessage(Long userId) {
        Message<Long> message = MessageBuilder.withPayload(userId).build();
        // delayLevel: 1=1s, 2=5s, 3=10s, ..., 16=2h
        rocketMQTemplate.syncSend("survey-topic", message, 3000, 14); // 延迟 10 分钟
    }
}

// 消费者: 处理消息
@Slf4j
@Component
@RocketMQMessageListener(
    topic = "user-registered-topic",
    consumerGroup = "email-consumer-group"
)
public class WelcomeEmailConsumer implements RocketMQListener<UserRegisteredEvent> {

    @Override
    public void onMessage(UserRegisteredEvent event) {
        log.info("发送欢迎邮件给用户: {}", event.getUsername());
        // 发送邮件逻辑...
    }
}

十四、定时任务

14.1 Spring 内置定时任务

适合简单的单机定时任务:

java
@Slf4j
@Component
@EnableScheduling
public class ScheduledTasks {

    // 每天凌晨 2 点执行 (Cron 表达式, 和 Node.js 的 node-cron 一样)
    @Scheduled(cron = "0 0 2 * * ?")
    public void cleanExpiredData() {
        log.info("开始清理过期数据...");
        // 清理逻辑
    }

    // 每 5 分钟执行一次
    @Scheduled(fixedRate = 300000)
    public void healthCheck() {
        log.info("执行健康检查...");
    }
}

14.2 分布式定时任务 (XXL-Job)

当应用部署多个实例时, 内置的 @Scheduled 每个实例都会执行。分布式场景需要用 XXL-Job 这类调度平台:

java
@Slf4j
@Component
public class DataSyncJob {

    @XxlJob("dataSyncJobHandler")
    public void execute() {
        log.info("XXL-Job 触发数据同步任务");
        // 分布式环境下只有一个实例会执行
    }
}

十五、项目实战架构

综合以上所有知识, 一个生产级的 Spring Boot 项目架构如下:

多模块项目结构

企业级项目通常采用多模块结构, 用 Gradle 或 Maven 管理:

my-project/
├── build.gradle                    # 根构建文件
├── settings.gradle                 # 模块注册
├── my-common/                      # 公共模块
│   ├── my-common-core/            # 核心工具类
│   └── my-common-pojo/            # 公共 POJO / DTO
├── my-platform/                    # 平台模块
│   └── my-platform-auth/          # 认证授权
├── my-user/                        # 用户模块
│   ├── my-user-common/            # 模块内公共
│   ├── my-user-service/           # 业务实现
│   └── my-user-api/               # Feign 接口 (微服务调用)
├── my-order/                       # 订单模块
│   ├── my-order-common/
│   ├── my-order-service/
│   └── my-order-api/
└── docker-compose.yml              # 基础设施

十六、Java 工具链全景 (对照前端生态)

16.1 核心工具生态对比

Java 的工具链按职责分为五层, 每层都能在前端找到对应物:

核心认知差异

  • Java 的构建工具 (Maven/Gradle) 管到打包, 生产启动直接用 java -jar
  • 前端的构建工具 (Webpack/Vite) 只负责打包, 运行靠 node server.js
  • 两者的工具重叠度在 "依赖管理 + 打包" 这两个职责上, 但启动方式完全不同

16.2 构建工具选型 (Maven vs Gradle)

维度MavenGradle前端类比
配置语言XMLGroovy / Kotlin DSLMaven ≈ JSON, Gradle ≈ JS config
构建速度中等快 (增量构建 + 缓存)Gradle ≈ Turborepo 的缓存策略
学习曲线平缓 (约定多)陡峭 (灵活但复杂)Maven ≈ Next.js, Gradle ≈ Webpack
生态成熟度最全 (企业标配)增长中 (Android 标配)Maven ≈ npm 的地位
依赖仲裁路径最短优先最高版本优先两者都比 npm 扁平化更激进
Lockfile无官方标准需手动开启比 package-lock.json 松

选型建议: 企业 Java 项目默认 Maven, 大型 monorepo 或需要自定义构建流程时选 Gradle

16.3 依赖机制的三个关键差异

1. 没有 node_modules

结论: Java 项目目录干净, 删了重建秒级; 前端的 node_modules 可能几个 GB

2. 扁平 classpath 强制仲裁

text
// 前端: 允许嵌套, A 用 lodash@3, B 用 lodash@4 可共存
node_modules/
├── package-a/node_modules/lodash@3.x
└── package-b/node_modules/lodash@4.x

// Java: classpath 扁平, 同一类名只能有一份
// Maven 按 "路径最短" 仲裁, 距离相同则先声明者胜
// Gradle 按 "版本最高" 仲裁

后果: Java 的依赖冲突表现为运行时 NoSuchMethodError, 排查靠:

sh
./mvnw dependency:tree          # 类似 pnpm why
./gradlew dependencies --configuration runtimeClasspath

3. 生命周期约束 vs 自由脚本

Maven 有三套生命周期, 每个阶段顺序固定:

sh
# Maven 的 default 生命周期 (部分)
validate compile test package install deploy

# 执行 package 会自动先跑 validate + compile + test
./mvnw package    # 相当于强制跑了单测

# 前端的 npm scripts 完全自由
npm run build     # 不会自动跑测试, 除非你手动写 "prebuild": "test"

语义陷阱

  • mvn install: 推到 本地仓库 ~/.m2/repository (供其他项目引用)
  • mvn deploy: 推到 远程仓库 (类似 npm publish)
  • npm install: 拉依赖 (≈ mvn dependency:resolve)

三个 install 语义完全不同!

16.4 启动服务的两条路径

核心原则:

  • 开发用 IDE (能断点调试), 或用 mvnw spring-boot:run (类似 npm run dev)
  • 生产用 java -jar (不依赖构建工具), 外面套 systemd / K8s 管重启

不要用 mvn spring-boot:run 上生产 — 它依赖源码目录和构建工具, 重启信号处理不干净

16.5 版本管理 (类似 nvm)

sh
# SDKMAN (推荐, 类似 nvm)
sdk list java
sdk install java 21-tem
sdk use java 17.0.9-tem     # 当前 shell 切换

# 替代品
brew install jenv            # 需要手动配置 shims
brew install asdf            # 通用版本管理器
brew install mise            # Rust 实现的快速版本切换

16.6 运行时诊断 (Java 独有优势)

前端的运行时诊断主要靠 Chrome DevTools 或 node --inspect, 能力有限; Java 有一整套工具链, 核心场景是 不重启诊断线上问题

JDK 自带命令行工具

sh
jps -l                      # 列出 Java 进程和主类 (类似 ps aux | grep java)
jstack <pid>                # 打印线程栈, 查死锁和卡顿
jmap -histo <pid>           # 堆内对象分布, 查内存泄漏
jstat -gc <pid> 1000        # 每秒打印 GC 统计 (类似 top 的内存视图)
jcmd <pid> GC.heap_info     # 万能诊断入口, 逐步取代上面几个老命令

Arthas (阿里开源, 线上诊断神器)

sh
# 下载并 attach 到进程
curl -O https://arthas.aliyun.com/arthas-boot.jar
java -jar arthas-boot.jar

# 实时监控方法调用 (入参 + 返回值 + 耗时)
watch com.example.UserService getUser "{params, returnObj, costInMillis}" -x 2

# 查看 JVM 实时指标
dashboard

# 反编译线上 class (验证部署是否正确)
jad com.example.UserService

前端最接近的: Chrome DevTools 远程调试, 但成熟度差几个量级

图形化 Profiling

  • VisualVM / JDK Mission Control: 需单独下载, CPU / 内存 profiling
  • JFR (Java Flight Recorder): 低开销飞行记录仪, JDK 11+ 免费, 生产可用

16.7 测试与质量工具

Testcontainers 的威力

你熟 Docker, 这个工具会让你眼前一亮 — 它用 Docker 起 真实的 MySQL / Redis 跑集成测试:

java
@Container
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0");

@Test
void testRealDatabase() {
    // 测试代码连的是真 MySQL, 不是 mock, 没有 "测试通过但生产挂" 的尴尬
}

前端一般用 msw 拦 HTTP (假装有后端), Java 是 "真起一个后端"

Testcontainers 最初来自 Java 生态, 现已支持 Node.js / Python / Go 等语言

格式化与检查对照:

sh
# Spotless 一键格式化 (类似 Prettier)
./mvnw spotless:apply

# Checkstyle 风格检查 (类似 ESLint 的格式规则)
./mvnw checkstyle:check

# SpotBugs 静态缺陷扫描 (类似 ESLint 的逻辑规则)
./mvnw spotbugs:check

16.8 容器化与原生镜像

Jib: 不需要 Dockerfile

sh
# 直接构建镜像 (不需要 Docker daemon)
./mvnw jib:dockerBuild

# 推到远程仓库
./mvnw jib:build -Dimage=myrepo/myapp:1.0

优势: 分层缓存优化 (依赖层和代码层分离), 构建速度快

GraalVM Native Image

sh
# 编译成原生二进制 (启动毫秒级, 内存占用小)
./mvnw -Pnative native:compile

# 适合 Serverless / K8s 快速扩容场景
# 但编译慢, 反射 / 动态代理需要额外配置

前端类比: bun build --compile 也能编成二进制, 但两者机制差异很大 (GraalVM 是 AOT 编译, bun 是打包 V8 引擎)

16.9 完整对照速查表

Java前端说明
pom.xml / build.gradlepackage.json依赖声明 + 脚本 + 元信息
Maven / Gradlepnpm + Vite 合体一个工具管两件事: 依赖和打包
~/.m2/repositorypnpm 全局 store跨项目共享缓存 (但引用机制不同)
./mvnw / ./gradlewcorepack / packageManager锁构建工具版本
mvn packagevite build产出可部署产物
fat jardist/ + node_modulesjar 把依赖也塞进去了
java -jar app.jarnode server.js真正的启动命令
mvn spring-boot:runnpm run dev开发态, 带自动编译
spring-boot-devtoolsHMR但只能整应用重启, 不是模块级热替换
JUnit + MockitoVitest + vi.mock单测框架 + mock 工具
Testcontainers-Java 独有: 用 Docker 起真实依赖跑测试
SpotlessPrettier格式化
Checkstyle / SpotBugsESLint风格检查 + 缺陷扫描
ArthasChrome DevTools但 Arthas 能 attach 线上进程不重启诊断
Jibbuildpacks / nixpacks不写 Dockerfile 出镜像
Maven BOMpnpm catalog: (9.5+)统一依赖版本
mvn dependency:treepnpm why查依赖路径
Gradle build cacheTurborepo / Nx cache跨项目共享构建产物

编译是硬性要求

前端可以 node index.js 直接跑源码 (或 ts-node 即时编译), Java 必须先 .java → .class

例外: JDK 11+ 支持 java HelloWorld.java 单文件源码直跑 (背后还是先编译), 但多文件项目仍需 Maven/Gradle

所以 Java 里 "改代码生效" 最快是重启级别, 前端 HMR 那种体验不存在


十七、常用命令速查

构建与运行

sh
# Maven
./mvnw clean install            # 类似 npm install && npm run build
./mvnw spring-boot:run          # 类似 npm run dev
./mvnw package -DskipTests      # 打包跳过测试

# Gradle
./gradlew clean build           # 构建
./gradlew bootRun               # 开发运行
./gradlew bootJar               # 打成可执行 JAR

# Docker 部署
docker build -t my-app .
docker run -p 8080:8080 my-app

常用 Actuator 端点

sh
# 健康检查 (类似 Node.js 的 /healthz)
curl http://localhost:8080/actuator/health

# 查看所有配置
curl http://localhost:8080/actuator/env

# Prometheus 指标
curl http://localhost:8080/actuator/prometheus

十八、从 Node.js 到 Spring Boot 的思维转换

写给前端开发者的建议

  1. 拥抱 IDE: Java 开发离不开 IntelliJ IDEA, 它的代码补全、重构、调试能力远超 VS Code 写 Java 的体验
  2. 不要怕样板代码: Java 确实比 JS/TS 啰嗦, 但 Lombok + IDEA 快捷键可以大幅缓解
  3. 理解编译期 vs 运行时: Java 的很多错误在编译期就能发现, 这是优势而非束缚
  4. 善用 Spring 文档: Spring 官方文档 质量极高, 是最好的学习资源
  5. AI 辅助开发: 借助 AI 工具快速理解 Java 语法和 Spring 惯用写法, 可以大幅降低学习曲线